﻿Namespace Library
  Public Class MonitorRealtime
    Implements IDisposable

    Private WithEvents TimerRecheckQueues As New Timer()

    Private _monitoredPathWatchers As New List(Of OpenNETCF14.IO.FileSystemWatcher)
    Private _itemQueue As New Queue(Of LibraryMonitorRealtimeItem)
    Private _threadExit As Boolean = False
    Private _workerThread As Threading.Thread = Nothing
    Private _emas As List(Of Settings.AudioExtensionMap) = Nothing
    Private _emps As List(Of Settings.PlaylistExtensionMap) = Nothing
    Private _includedPaths As New List(Of String)
    Private _excludedPaths As New List(Of String)

    Public Event WriteErrorOnMainThread(ByVal err As String)
    Public Event MonitorUpdateStatusChanged(ByVal isUpdating As Boolean, ByVal path As String)
    Public Event UpdateManagedPlaylists(ByVal oldFileName As String, ByVal newFileName As String)
    Public Event DoFullScan()

    Public Sub New()
      Me.TimerRecheckQueues.Interval = 2000
    End Sub

    Public Sub StartRealtimeMonitor(ByVal ipaths As List(Of String), ByVal epaths As List(Of String))
      If Me._monitoredPathWatchers.Count <= 0 Then ' Monitor is already running if greater than 0
        Me._includedPaths.Clear()
        Me._excludedPaths.Clear()

        ' Sanatize paths
        For Each path As String In ipaths
          If IO.Directory.Exists(path) Then
            Me._includedPaths.Add(path)
          Else
            ErrorLog.WriteToErrorLog("Unable to start realtime monitor on path [ " & path & " ] due to failure with IO.Directory.Exists check.")
          End If
        Next

        Me._excludedPaths.AddRange(epaths)

        ' Now we start up the realtime monitors
        If Me._includedPaths.Count > 0 Then
          Me._threadExit = False
          Me.KillWatchers()

          SyncLock Me._itemQueue
            Me._itemQueue.Clear()
          End SyncLock

          Me._emas = Settings.GetAudioExtensionMaps
          Me._emps = Settings.GetPlaylistExtensionMaps

          ' Scans based on file last write time (essentially, a file is modified at some point)
          For Each path As String In Me._includedPaths
            For Each em As Settings.AudioExtensionMap In Me._emas
              Dim fsw As New OpenNETCF14.IO.FileSystemWatcher(path, em.FileSpec)
              fsw.NotifyFilter = OpenNETCF14.IO.NotifyFilters.LastWrite Or OpenNETCF14.IO.NotifyFilters.FileName
              fsw.IncludeSubdirectories = True
              fsw.EnableRaisingEvents = True
              AddHandler fsw.Changed, AddressOf Fsw_FileModifyChanged
              Me._monitoredPathWatchers.Add(fsw)
            Next
            For Each em As Settings.PlaylistExtensionMap In Me._emps
              Dim fsw As New OpenNETCF14.IO.FileSystemWatcher(path, em.FileSpec)
              fsw.NotifyFilter = OpenNETCF14.IO.NotifyFilters.LastWrite Or OpenNETCF14.IO.NotifyFilters.FileName
              fsw.IncludeSubdirectories = True
              fsw.EnableRaisingEvents = True
              AddHandler fsw.Changed, AddressOf Fsw_FileModifyChanged
              Me._monitoredPathWatchers.Add(fsw)
            Next
          Next

          ' Scans based on filename (note: 'changed' is sometimes fired for files that are CREATED)
          For Each path As String In Me._includedPaths
            For Each em As Settings.AudioExtensionMap In Me._emas
              Dim fsw As New OpenNETCF14.IO.FileSystemWatcher(path, em.FileSpec)
              fsw.NotifyFilter = OpenNETCF14.IO.NotifyFilters.LastWrite Or OpenNETCF14.IO.NotifyFilters.FileName
              fsw.IncludeSubdirectories = True
              fsw.EnableRaisingEvents = True
              AddHandler fsw.Created, AddressOf Fsw_FileNameChanged
              AddHandler fsw.Deleted, AddressOf Fsw_FileNameChanged
              AddHandler fsw.Changed, AddressOf Fsw_FileNameChanged
              AddHandler fsw.Renamed, AddressOf Fsw_Renamed
              Me._monitoredPathWatchers.Add(fsw)
            Next
            For Each em As Settings.PlaylistExtensionMap In Me._emps
              Dim fsw As New OpenNETCF14.IO.FileSystemWatcher(path, em.FileSpec)
              fsw.NotifyFilter = OpenNETCF14.IO.NotifyFilters.LastWrite Or OpenNETCF14.IO.NotifyFilters.FileName
              fsw.IncludeSubdirectories = True
              fsw.EnableRaisingEvents = True
              AddHandler fsw.Created, AddressOf Fsw_FileNameChanged
              AddHandler fsw.Deleted, AddressOf Fsw_FileNameChanged
              AddHandler fsw.Changed, AddressOf Fsw_FileNameChanged
              AddHandler fsw.Renamed, AddressOf Fsw_Renamed
              Me._monitoredPathWatchers.Add(fsw)
            Next
          Next

          ' Scans based on directory names created, deleted, or renamed (all directory operations)
          For Each path As String In Me._includedPaths
            Dim fsw As New OpenNETCF14.IO.FileSystemWatcher(path)
            fsw.NotifyFilter = OpenNETCF14.IO.NotifyFilters.DirectoryName
            fsw.IncludeSubdirectories = True
            fsw.EnableRaisingEvents = True
            AddHandler fsw.Created, AddressOf Fsw_FolderChanged
            AddHandler fsw.Deleted, AddressOf Fsw_FolderChanged
            AddHandler fsw.Renamed, AddressOf Fsw_Renamed
            Me._monitoredPathWatchers.Add(fsw)
          Next
        End If
      End If
    End Sub

    Private Sub StartRealtimeThread()
      If Me._workerThread Is Nothing Then
        ' Set up thread
        Me._workerThread = New Threading.Thread(AddressOf WorkerThread)
        Me._workerThread.Name = "WorkerThread"
        Me._workerThread.Priority = Threading.ThreadPriority.Lowest
        Me._workerThread.IsBackground = True
        Me._threadExit = False

        ' Add thread to the thread pool
        SyncLock MainThreadPool
          MainThreadPool.Add(Me._workerThread)
        End SyncLock

        ' Start the thread
        Me._workerThread.Start()
      End If
    End Sub

    Public Sub EndRealtimeMonitor()
      Me.KillWatchers()
    End Sub

    Private Sub Fsw_FolderChanged(ByVal sender As Object, ByVal e As OpenNETCF14.IO.FileSystemEventArgs)
      Dim item As New LibraryMonitorRealtimeItem

      item.Action = LibraryMonitorRealtimeItem.ActionType.FullScan
      item.FullPath = e.FullPath
      item.Name = e.Name

      SyncLock Me._itemQueue
        Me._itemQueue.Enqueue(item)
      End SyncLock

      Me.StartRealtimeThread()
    End Sub

    Private Sub Fsw_FileNameChanged(ByVal sender As Object, ByVal e As OpenNETCF14.IO.FileSystemEventArgs)
      Dim item As New LibraryMonitorRealtimeItem

      If IO.File.Exists(e.FullPath) Then
        item.Action = LibraryMonitorRealtimeItem.ActionType.Created
      Else
        item.Action = LibraryMonitorRealtimeItem.ActionType.Deleted
      End If

      item.FullPath = e.FullPath
      item.Name = e.Name

      SyncLock Me._itemQueue
        Me._itemQueue.Enqueue(item)
      End SyncLock

      'ErrorLog.WriteToErrorLog("FSW: " & e.ChangeType.ToString & " : " & e.FullPath)

      Me.StartRealtimeThread()
    End Sub

    Private Sub Fsw_FileModifyChanged(ByVal sender As Object, ByVal e As OpenNETCF14.IO.FileSystemEventArgs)
      Dim item As New LibraryMonitorRealtimeItem

      If IO.File.Exists(e.FullPath) Then
        item.Action = LibraryMonitorRealtimeItem.ActionType.Updated
      Else
        item.Action = LibraryMonitorRealtimeItem.ActionType.Deleted
      End If

      item.FullPath = e.FullPath
      item.Name = e.Name

      SyncLock Me._itemQueue
        Me._itemQueue.Enqueue(item)
      End SyncLock

      'ErrorLog.WriteToErrorLog("FSW: " & e.ChangeType.ToString & " : " & e.FullPath)

      Me.StartRealtimeThread()
    End Sub

    Private Sub Fsw_Renamed(ByVal sender As Object, ByVal e As OpenNETCF14.IO.RenamedEventArgs)
      Dim item As New LibraryMonitorRealtimeItem

      ' The SDF has a bug where the fullpath is actually oldfullpath, oldname is the full path, and oldfullpath is just the folder (no name).  well, we don't need the name, so we just assign the way that's right.  This is the 1.4 version we're using because the 2.3 version is even more messed up (all paths point to the same (new) file).
      item.OldFullPath = e.FullPath
      item.FullPath = e.OldName
      item.Name = e.Name

      If IO.Directory.Exists(item.FullPath) Then
        ' This is a directory, not a file
        item.Action = LibraryMonitorRealtimeItem.ActionType.FullScan
      Else
        item.Action = LibraryMonitorRealtimeItem.ActionType.Renamed
      End If

      SyncLock Me._itemQueue
        Me._itemQueue.Enqueue(item)
      End SyncLock

      'ErrorLog.WriteToErrorLog("FSW: " & e.ChangeType.ToString & " : " & e.FullPath)

      Me.StartRealtimeThread()
    End Sub

    Public Sub UpdateFileNow(ByVal filePath As String) ' For manually updating a file from somewhere else
      If IO.File.Exists(filePath) Then
        Dim item As New LibraryMonitorRealtimeItem
        item.Action = LibraryMonitorRealtimeItem.ActionType.Updated
        item.FullPath = filePath
        item.Name = IO.Path.GetFileName(filePath)

        SyncLock Me._itemQueue
          Me._itemQueue.Enqueue(item)
        End SyncLock

        Me.StartRealtimeThread()
      End If
    End Sub

    Public Sub StopRealtimeThread()
      Me._threadExit = True
    End Sub

    Private Sub WorkerThread()
      Try
        If Me._itemQueue.Count > 0 AndAlso Not Me._threadExit Then
          Using db As New AvianPlayDB
            While Me._itemQueue.Count > 0 AndAlso Not Me._threadExit
              While Me._itemQueue.Count > 0 AndAlso Not Me._threadExit ' Two whiles, because we sleep on the outside of one to wait for events to trickle in
                Try
                  Dim item As LibraryMonitorRealtimeItem
                  SyncLock Me._itemQueue
                    item = Me._itemQueue.Dequeue
                  End SyncLock

                  Dim fileIsExcluded As Boolean = True

                  If item IsNot Nothing AndAlso item.Action <> LibraryMonitorRealtimeItem.ActionType.None Then
                    Dim ema As Settings.AudioExtensionMap = Nothing
                    Dim emp As Settings.PlaylistExtensionMap = Nothing

                    ' Dedupe the queue (make sure we aren't going to update again after this update for the same file with no changes (additional changes can still trickle in after this file is processed))
                    Me.DeDupeItemQueue(item)

                    ' Find the extension map
                    For Each emi As Settings.AudioExtensionMap In Me._emas
                      If String.Compare(Right(item.Name, emi.Extension.Length), emi.Extension, True) = 0 Then
                        ema = emi
                      End If
                    Next
                    If ema Is Nothing Then
                      For Each emi As Settings.PlaylistExtensionMap In Me._emps
                        If String.Compare(Right(item.Name, emi.Extension.Length), emi.Extension, True) = 0 Then
                          emp = emi
                        End If
                      Next
                    End If

                    ' Check to see if the destination path (in any of the cases) is both IN a monitored path and NOT IN an excluded path
                    If Library.Monitor.IsIncludedPath(item.FullPath, Me._includedPaths) Then
                      fileIsExcluded = False
                    End If
                    If Library.Monitor.IsExcludedPath(item.FullPath, Me._excludedPaths) Then
                      fileIsExcluded = True
                    End If

                    Select Case item.Action
                      Case LibraryMonitorRealtimeItem.ActionType.Renamed
                        ' Notify we are making changes
                        RaiseEvent MonitorUpdateStatusChanged(True, item.FullPath)

                        ' File was moved outside of a monitored path (set both paths, just in case)
                        If fileIsExcluded Then
                          db.SetAudioFileNotFoundStatus(item.OldFullPath) = True
                          db.SetAudioFileNotFoundStatus(item.FullPath) = True
                        Else
                          ' See if the old path was excluded
                          Dim oldIsExcluded As Boolean = True

                          If Library.Monitor.IsIncludedPath(item.OldFullPath, Me._includedPaths) Then
                            oldIsExcluded = False
                          End If
                          If Library.Monitor.IsExcludedPath(item.OldFullPath, Me._excludedPaths) Then
                            oldIsExcluded = True
                          End If

                          ' If the old is excluded, this is a new file to Avian Play potentially, so we run the standard update.  Note that it has to have a valid extension map
                          If oldIsExcluded AndAlso (ema IsNot Nothing OrElse emp IsNot Nothing) Then
                            Dim err As String = String.Empty
                            If ema IsNot Nothing Then err = Library.Monitor.UpdateAudioFileInDatabase(item.FullPath, ema, db)
                            If emp IsNot Nothing Then err = Library.Monitor.UpdatePlaylistFileInDatabase(item.FullPath, emp, db)
                            If err.Length > 0 Then
                              RaiseEvent WriteErrorOnMainThread(err)
                            End If
                          ElseIf ema Is Nothing AndAlso emp Is Nothing Then
                            ' Invalid extension now, so it may have been valid before, so we try to remove the remove old entry, if it exists (we don't know if it's audio or playlist, so we nuke both)
                            db.SetAudioFileNotFoundStatus(item.OldFullPath) = True
                            db.SetPlaylistFileNotFoundStatus(item.OldFullPath) = True
                            RaiseEvent UpdateManagedPlaylists(item.OldFullPath, String.Empty)
                          ElseIf ema IsNot Nothing Then
                            ' Not a new file, not invalid for any reason, so just do a standard rename operation
                            Dim err As String = db.RenameFileDatabaseEntry(item.OldFullPath, item.FullPath)

                            ' Set the file udpate to mindate, because the following operations may be terminated before it completes and this will cause the next manual update to trigger on the next application load
                            db.SetAudioFileModifyDate(item.FullPath) = AvianPlayDB.MinimumDateTime

                            If err.Length > 0 Then
                              RaiseEvent WriteErrorOnMainThread(err)

                              ' There was an error renaming, so remove the old file name
                              db.SetAudioFileNotFoundStatus(item.OldFullPath) = True
                            End If

                            ' Now run a standard update to update tags (for path-derived tags)
                            err = Library.Monitor.UpdateAudioFileInDatabase(item.FullPath, ema, db)
                            If err.Length > 0 Then
                              RaiseEvent WriteErrorOnMainThread(err)
                            End If

                            ' Notify Avian Play that it should update this file in any loaded playlists where this file is referenced
                            RaiseEvent UpdateManagedPlaylists(item.OldFullPath, item.FullPath)
                          Else
                            ' Not a new file, not invalid for any reason, so just do a standard rename operation
                            Dim err As String = db.RenamePlaylistDatabaseEntry(item.OldFullPath, item.FullPath)
                            If err.Length > 0 Then
                              RaiseEvent WriteErrorOnMainThread(err)

                              ' There was an error renaming, so run an update on the new file name and remove the old file name
                              db.SetPlaylistFileNotFoundStatus(item.OldFullPath) = True
                              err = Library.Monitor.UpdatePlaylistFileInDatabase(item.FullPath, emp, db)
                              If err.Length > 0 Then
                                RaiseEvent WriteErrorOnMainThread(err)
                              End If
                            End If
                          End If
                        End If

                      Case LibraryMonitorRealtimeItem.ActionType.Deleted
                        If ema IsNot Nothing OrElse emp IsNot Nothing Then
                          ' Notify we are making changes
                          RaiseEvent MonitorUpdateStatusChanged(True, item.FullPath)
                        End If

                        ' Attempt delete audio
                        If ema IsNot Nothing Then
                          db.SetAudioFileNotFoundStatus(item.FullPath) = True

                          ' Notify Avian Play that it should remove this file in any loaded playlists where this file is referenced
                          RaiseEvent UpdateManagedPlaylists(item.FullPath, String.Empty)
                        End If

                        ' attempt delete playlist
                        If emp IsNot Nothing Then
                          db.SetPlaylistFileNotFoundStatus(item.FullPath) = True
                        End If

                        ' delete both because it's an unknown extension type
                        If ema Is Nothing AndAlso emp Is Nothing Then
                          db.SetAudioFileNotFoundStatus(item.FullPath) = True
                          db.SetPlaylistFileNotFoundStatus(item.FullPath) = True
                          RaiseEvent UpdateManagedPlaylists(item.FullPath, String.Empty)
                        End If

                      Case LibraryMonitorRealtimeItem.ActionType.Created, LibraryMonitorRealtimeItem.ActionType.Updated
                        ' Before doing anything, check if the file exists.  UPDATED is sometimes queued right before DELETED.
                        If Not fileIsExcluded AndAlso IO.File.Exists(item.FullPath) AndAlso New IO.FileInfo(item.FullPath).Length > 0 Then
                          If ema IsNot Nothing Then
                            ' If we HAVE the file already in the db, don't update it, so we don't cause a race condition
                            If item.Action = LibraryMonitorRealtimeItem.ActionType.Created AndAlso db.GetAudioFileLastModifyDate(item.FullPath) > Date.MinValue Then
                              ' File exists in db, so don't do anything
                            Else
                              ' Notify we are making changes
                              RaiseEvent MonitorUpdateStatusChanged(True, item.FullPath)

                              ' Perform the update on the existing file
                              Dim err As String = Library.Monitor.UpdateAudioFileInDatabase(item.FullPath, ema, db)
                              If err.Length > 0 Then
                                RaiseEvent WriteErrorOnMainThread(err)
                              End If
                            End If
                          End If

                          If emp IsNot Nothing Then
                            ' If we HAVE the file already in the db, don't update it, so we don't cause a race condition
                            If item.Action = LibraryMonitorRealtimeItem.ActionType.Created AndAlso db.GetAudioFileLastModifyDate(item.FullPath) > Date.MinValue Then
                              ' File exists in db, so don't do anything
                            Else
                              ' Notify we are making changes
                              RaiseEvent MonitorUpdateStatusChanged(True, item.FullPath)

                              ' Perform the update on the existing file
                              Dim err As String = Library.Monitor.UpdatePlaylistFileInDatabase(item.FullPath, emp, db)
                              If err.Length > 0 Then
                                RaiseEvent WriteErrorOnMainThread(err)
                              End If
                            End If
                          End If
                        End If

                      Case LibraryMonitorRealtimeItem.ActionType.FullScan
                        If Not Me._threadExit Then
                          RaiseEvent DoFullScan()
                        End If
                    End Select
                  End If

                  ' Unset any update notification
                  RaiseEvent MonitorUpdateStatusChanged(False, String.Empty)

                Catch ex As Threading.ThreadAbortException
                  Throw ' down to the bottom level, so the function exits
                Catch ex As Exception
                  RaiseEvent WriteErrorOnMainThread("Realtime Monitor Thread :: Processing Item :: " & ex.ToString)
                End Try

                ' Wait a ms inbetween each file, to allow threads to execute that are at a lower priority (seems to help unlock activesync)
                If Not Me._threadExit Then
                  Threading.Thread.Sleep(1)
                End If
              End While

              ' Wait ~1 second to see if any new events come in
              If Not Me._threadExit Then
                Threading.Thread.Sleep(1000)
              End If
            End While
          End Using
        End If

        ' To make sure we don't miss anything from asychronous processing on the main thread, we start a timer to run this function again after a bit (this is in addition to the sleep, which is used to just not immediately destroy the DB object)
        If Not Me._threadExit Then
          Me.TimerRecheckQueues.Enabled = True
        End If

      Catch ex As Threading.ThreadAbortException
        ' eat this exception
      Catch ex As Exception
        RaiseEvent WriteErrorOnMainThread("Realtime Monitor Thread :: " & ex.ToString)
      Finally
        SyncLock MainThreadPool
          MainThreadPool.Remove(Me._workerThread)
        End SyncLock
        Me._workerThread = Nothing
      End Try
    End Sub

    Private Sub DeDupeItemQueue(ByVal compareItem As LibraryMonitorRealtimeItem)
      SyncLock Me._itemQueue
        With Me._itemQueue.GetEnumerator
          While .MoveNext
            If compareItem.Equals(.Current) Then
              .Current.Action = LibraryMonitorRealtimeItem.ActionType.None
            End If
          End While
        End With
      End SyncLock
    End Sub

    Public ReadOnly Property IsMonitorActive() As Boolean
      Get
        Return Not (Me._workerThread Is Nothing) Or (Me._monitoredPathWatchers.Count > 0)
      End Get
    End Property

    Private Sub KillWatchers()
      Me.TimerRecheckQueues.Enabled = False

      For Each fsw As OpenNETCF14.IO.FileSystemWatcher In Me._monitoredPathWatchers
        fsw.EnableRaisingEvents = False
        Try : RemoveHandler fsw.Created, AddressOf Fsw_FileNameChanged : Catch : End Try
        Try : RemoveHandler fsw.Changed, AddressOf Fsw_FileNameChanged : Catch : End Try
        Try : RemoveHandler fsw.Deleted, AddressOf Fsw_FileNameChanged : Catch : End Try
        Try : RemoveHandler fsw.Created, AddressOf Fsw_FileModifyChanged : Catch : End Try
        Try : RemoveHandler fsw.Changed, AddressOf Fsw_FileModifyChanged : Catch : End Try
        Try : RemoveHandler fsw.Deleted, AddressOf Fsw_FileModifyChanged : Catch : End Try
        Try : RemoveHandler fsw.Created, AddressOf Fsw_FolderChanged : Catch : End Try
        Try : RemoveHandler fsw.Changed, AddressOf Fsw_FolderChanged : Catch : End Try
        Try : RemoveHandler fsw.Deleted, AddressOf Fsw_FolderChanged : Catch : End Try
        Try : RemoveHandler fsw.Renamed, AddressOf Fsw_Renamed : Catch : End Try
        fsw.Dispose()
      Next

      Me._monitoredPathWatchers.Clear()
    End Sub

    Private Sub TimerRecheckQueues_Tick(ByVal sender As Object, ByVal e As EventArgs) Handles TimerRecheckQueues.Tick
      Me.TimerRecheckQueues.Enabled = False

      If Not Me._threadExit Then
        Me.StartRealtimeThread()
      End If
    End Sub


#Region " IDisposable Support "
    Private disposedValue As Boolean = False    ' To detect redundant calls

    ' IDisposable
    Protected Overridable Sub Dispose(ByVal disposing As Boolean)
      If Not Me.disposedValue Then
        If disposing Then
          Me.EndRealtimeMonitor()
        End If
      End If
      Me.disposedValue = True
    End Sub

    ' This code added by Visual Basic to correctly implement the disposable pattern.
    Public Sub Dispose() Implements IDisposable.Dispose
      ' Do not change this code.  Put cleanup code in Dispose(ByVal disposing As Boolean) above.
      Dispose(True)
      GC.SuppressFinalize(Me)
    End Sub
#End Region

  End Class
End Namespace